1use rusqlite::Connection;
20use tracing::warn;
21
22use super::error::StoreError;
23
24pub const OBJECT_SCHEMA_VERSION: i64 = 9;
48pub const FILESTORE_SCHEMA_VERSION: i64 = 1;
50pub const SAGA_LOG_SCHEMA_VERSION: i64 = 1;
52
53const WSTORE_OTYPES: &[&str] = &[
55 "client",
56 "window",
57 "workspace",
58 "tab",
59 "layout",
60 "block",
61 "temp",
62];
63
64const LEGACY_TABLE_RENAMES: &[(&str, &str)] = &[
69 ("db_forge_agents", "db_agent_definitions"),
70 ("db_forge_content", "db_agent_content"),
71 ("db_forge_skills", "db_agent_skills"),
72 ("db_forge_history", "db_agent_history"),
73 ("db_forge_agent_identities", "db_agent_identity_links"),
74 ("db_identities", "db_identity_bundles"),
75 ("db_memories", "db_memory_bundles"),
76];
77
78const LEGACY_INDEX_DROPS: &[&str] = &[
83 "idx_forge_agents_slug",
84 "idx_forge_history_agent_date",
85 "idx_forge_agent_identities_account",
86 "idx_identities_is_blank",
87 "idx_memories_is_blank",
88];
89
90const DEAD_TABLE_DROPS: &[&str] = &[
95 "db_workflow_definitions",
96 "db_workflow_runs",
97 "db_v10_migrated_legacy_defs",
98 "db_v10_migrated_legacy_runs",
99];
100
101pub fn run_object_schema(conn: &Connection) -> Result<(), StoreError> {
118 adopt_legacy_table_names(conn)?;
119
120 for otype in WSTORE_OTYPES {
122 conn.execute_batch(&format!(
123 "CREATE TABLE IF NOT EXISTS db_{otype} (
124 oid TEXT PRIMARY KEY,
125 version INTEGER NOT NULL DEFAULT 1,
126 data TEXT NOT NULL
127 );"
128 ))?;
129 }
130
131 conn.execute_batch(
133 "CREATE TABLE IF NOT EXISTS db_agent_definitions (
134 id TEXT PRIMARY KEY,
135 slug TEXT NOT NULL DEFAULT '',
136 name TEXT NOT NULL,
137 icon TEXT NOT NULL DEFAULT '✦',
138 provider TEXT NOT NULL,
139 description TEXT NOT NULL DEFAULT '',
140 working_directory TEXT NOT NULL DEFAULT '',
141 shell TEXT NOT NULL DEFAULT '',
142 provider_flags TEXT NOT NULL DEFAULT '',
143 auto_start INTEGER NOT NULL DEFAULT 0,
144 restart_on_crash INTEGER NOT NULL DEFAULT 0,
145 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
146 agent_type TEXT NOT NULL DEFAULT 'standalone',
147 environment TEXT NOT NULL DEFAULT '',
148 agent_bus_id TEXT NOT NULL DEFAULT '',
149 is_seeded INTEGER NOT NULL DEFAULT 0,
150 accounts TEXT NOT NULL DEFAULT '',
151 parent_id TEXT NOT NULL DEFAULT '',
152 branch_label TEXT NOT NULL DEFAULT '',
153 created_at INTEGER NOT NULL DEFAULT 0,
154 updated_at INTEGER NOT NULL DEFAULT 0,
155 user_hidden INTEGER NOT NULL DEFAULT 0,
156 container_image TEXT NOT NULL DEFAULT '',
157 container_volumes TEXT NOT NULL DEFAULT '[]',
158 container_name TEXT NOT NULL DEFAULT ''
159 );
160 CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_definitions_slug
161 ON db_agent_definitions(slug);
162
163 CREATE TABLE IF NOT EXISTS db_agent_content (
164 agent_id TEXT NOT NULL,
165 content_type TEXT NOT NULL,
166 content TEXT NOT NULL DEFAULT '',
167 updated_at INTEGER NOT NULL DEFAULT 0,
168 PRIMARY KEY (agent_id, content_type),
169 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
170 );
171
172 CREATE TABLE IF NOT EXISTS db_agent_skills (
173 id TEXT PRIMARY KEY,
174 agent_id TEXT NOT NULL,
175 name TEXT NOT NULL,
176 trigger TEXT NOT NULL DEFAULT '',
177 skill_type TEXT NOT NULL DEFAULT 'prompt',
178 description TEXT NOT NULL DEFAULT '',
179 content TEXT NOT NULL DEFAULT '',
180 created_at INTEGER NOT NULL DEFAULT 0,
181 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
182 );
183
184 CREATE TABLE IF NOT EXISTS db_agent_history (
185 id INTEGER PRIMARY KEY AUTOINCREMENT,
186 agent_id TEXT NOT NULL,
187 session_date TEXT NOT NULL,
188 entry TEXT NOT NULL,
189 timestamp INTEGER NOT NULL DEFAULT 0,
190 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
191 );
192 CREATE INDEX IF NOT EXISTS idx_agent_history_agent_date
193 ON db_agent_history(agent_id, session_date);
194
195 CREATE TABLE IF NOT EXISTS db_identity_accounts (
196 id TEXT PRIMARY KEY,
197 name TEXT NOT NULL,
198 provider TEXT NOT NULL,
199 kind TEXT NOT NULL,
200 display_name TEXT NOT NULL DEFAULT '',
201 secret_ref TEXT NOT NULL,
202 context TEXT NOT NULL DEFAULT '{}',
203 status TEXT NOT NULL DEFAULT 'unknown',
204 created_at INTEGER NOT NULL DEFAULT 0,
205 updated_at INTEGER NOT NULL DEFAULT 0
206 );
207 CREATE INDEX IF NOT EXISTS idx_identity_accounts_provider
208 ON db_identity_accounts(provider);
209
210 CREATE TABLE IF NOT EXISTS db_agent_identity_links (
211 agent_id TEXT NOT NULL,
212 account_id TEXT NOT NULL,
213 provider TEXT NOT NULL,
214 PRIMARY KEY (agent_id, provider),
215 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE,
216 FOREIGN KEY (account_id) REFERENCES db_identity_accounts(id) ON DELETE CASCADE
217 );
218 CREATE INDEX IF NOT EXISTS idx_agent_identity_links_account
219 ON db_agent_identity_links(account_id);
220
221 CREATE TABLE IF NOT EXISTS db_identity_bundles (
222 id TEXT PRIMARY KEY,
223 name TEXT NOT NULL UNIQUE,
224 description TEXT NOT NULL DEFAULT '',
225 is_blank INTEGER NOT NULL DEFAULT 0,
226 created_at INTEGER NOT NULL DEFAULT 0,
227 updated_at INTEGER NOT NULL DEFAULT 0
228 );
229 CREATE INDEX IF NOT EXISTS idx_identity_bundles_is_blank
230 ON db_identity_bundles(is_blank);
231
232 CREATE TABLE IF NOT EXISTS db_identity_bindings (
233 identity_id TEXT NOT NULL,
234 provider TEXT NOT NULL,
235 account_id TEXT NOT NULL,
236 PRIMARY KEY (identity_id, provider),
237 FOREIGN KEY (identity_id) REFERENCES db_identity_bundles(id) ON DELETE CASCADE,
238 FOREIGN KEY (account_id) REFERENCES db_identity_accounts(id) ON DELETE CASCADE
239 );
240 CREATE INDEX IF NOT EXISTS idx_identity_bindings_account
241 ON db_identity_bindings(account_id);
242
243 CREATE TABLE IF NOT EXISTS db_memory_bundles (
244 id TEXT PRIMARY KEY,
245 name TEXT NOT NULL UNIQUE,
246 description TEXT NOT NULL DEFAULT '',
247 is_blank INTEGER NOT NULL DEFAULT 0,
248 is_global INTEGER NOT NULL DEFAULT 0,
249 provider TEXT NOT NULL DEFAULT '',
250 model TEXT NOT NULL DEFAULT '',
251 instructions TEXT NOT NULL DEFAULT '',
252 context_files TEXT NOT NULL DEFAULT '[]',
253 mcp_servers TEXT NOT NULL DEFAULT '[]',
254 skills TEXT NOT NULL DEFAULT '[]',
255 sort_order INTEGER NOT NULL DEFAULT 0,
256 created_at INTEGER NOT NULL DEFAULT 0,
257 updated_at INTEGER NOT NULL DEFAULT 0
258 );
259 CREATE INDEX IF NOT EXISTS idx_memory_bundles_is_blank
260 ON db_memory_bundles(is_blank);
261
262 CREATE TABLE IF NOT EXISTS db_agent_instances (
263 id TEXT PRIMARY KEY,
264 definition_id TEXT NOT NULL,
265 parent_instance_id TEXT NOT NULL DEFAULT '',
266 block_id TEXT NOT NULL DEFAULT '',
267 session_id TEXT NOT NULL DEFAULT '',
268 status TEXT NOT NULL DEFAULT 'running',
269 github_context TEXT NOT NULL DEFAULT '',
270 identity_id TEXT NOT NULL DEFAULT '',
271 memory_id TEXT NOT NULL DEFAULT '',
272 instance_name TEXT NOT NULL DEFAULT '',
273 working_directory TEXT NOT NULL DEFAULT '',
274 display_hidden INTEGER NOT NULL DEFAULT 0,
275 started_at INTEGER NOT NULL DEFAULT 0,
276 ended_at INTEGER NOT NULL DEFAULT 0,
277 created_at INTEGER NOT NULL DEFAULT 0,
278 FOREIGN KEY (definition_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
279 );
280 CREATE INDEX IF NOT EXISTS idx_agent_instances_definition
281 ON db_agent_instances(definition_id);
282 CREATE INDEX IF NOT EXISTS idx_agent_instances_block
283 ON db_agent_instances(block_id);
284 CREATE INDEX IF NOT EXISTS idx_agent_instances_status
285 ON db_agent_instances(status);
286 CREATE INDEX IF NOT EXISTS idx_agent_instances_parent
287 ON db_agent_instances(parent_instance_id);
288 CREATE INDEX IF NOT EXISTS idx_agent_instances_name_recent
289 ON db_agent_instances(instance_name, started_at DESC)
290 WHERE display_hidden = 0 AND instance_name != '';
291
292 -- Phase 3a consolidation: `db_agents` collapses `db_agent_definitions`
293 -- + `db_agent_instances` into one table per
294 -- `docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md`.
295 -- WRITE-ONLY in 3a: every old-table mutation dual-writes here, but
296 -- every read still hits the old tables. Phase 3b migrates readers,
297 -- Phase 3c drops the old tables. Column names align with the live
298 -- `db_agent_definitions` shape (provider, working_directory, …) —
299 -- NOT the inline draft in the spec (provider_id, cmd, cmd_args, …) —
300 -- because the existing storage layer never grew the cmd-template
301 -- columns the spec sketched; carrying the names we actually have
302 -- avoids inventing data we don't store. See the PR body for the
303 -- field-by-field mapping.
304 CREATE TABLE IF NOT EXISTS db_agents (
305 id TEXT PRIMARY KEY,
306 name TEXT NOT NULL,
307 icon TEXT NOT NULL DEFAULT '',
308 description TEXT NOT NULL DEFAULT '',
309
310 -- Template vs user agent
311 is_template INTEGER NOT NULL DEFAULT 0,
312 parent_template_id TEXT NOT NULL DEFAULT '',
313
314 -- Provider/cmd config (was on definition; named to match the
315 -- live `db_agent_definitions` columns).
316 provider TEXT NOT NULL,
317 provider_flags TEXT NOT NULL DEFAULT '',
318 shell TEXT NOT NULL DEFAULT '',
319 environment TEXT NOT NULL DEFAULT '',
320 agent_type TEXT NOT NULL DEFAULT 'standalone',
321 agent_bus_id TEXT NOT NULL DEFAULT '',
322 accounts TEXT NOT NULL DEFAULT '',
323 auto_start INTEGER NOT NULL DEFAULT 0,
324 restart_on_crash INTEGER NOT NULL DEFAULT 0,
325 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
326 slug TEXT NOT NULL DEFAULT '',
327 branch_label TEXT NOT NULL DEFAULT '',
328
329 -- Bindings (was on instance — only meaningful when is_template=0).
330 -- For template rows these stay empty.
331 identity_id TEXT NOT NULL DEFAULT '',
332 memory_id TEXT NOT NULL DEFAULT '',
333 working_directory TEXT NOT NULL DEFAULT '',
334 github_context TEXT NOT NULL DEFAULT '',
335 instance_name TEXT NOT NULL DEFAULT '',
336
337 -- Latest launch's block (Phase 3c): pointer to the most-recent
338 -- session's block so the consolidated read can locate the
339 -- conversation snapshot without joining db_agent_instances. The
340 -- only transient per-launch field db_agents retains; the rest
341 -- (status/session_id/started_at/ended_at) live on the block and
342 -- retire with db_agent_instances.
343 last_block_id TEXT NOT NULL DEFAULT '',
344
345 -- Provenance
346 created_at INTEGER NOT NULL DEFAULT 0,
347 updated_at INTEGER NOT NULL DEFAULT 0,
348 is_seeded INTEGER NOT NULL DEFAULT 0,
349 user_hidden INTEGER NOT NULL DEFAULT 0,
350
351 -- Container support (Schema v6 / Phase 0).
352 -- Empty for host agents; populated by ContainerManager.
353 container_image TEXT NOT NULL DEFAULT '',
354 container_volumes TEXT NOT NULL DEFAULT '[]',
355 container_name TEXT NOT NULL DEFAULT ''
356 );
357 CREATE INDEX IF NOT EXISTS idx_agents_is_template
358 ON db_agents(is_template);
359 CREATE INDEX IF NOT EXISTS idx_agents_parent_template_id
360 ON db_agents(parent_template_id);
361 CREATE INDEX IF NOT EXISTS idx_agents_is_seeded
362 ON db_agents(is_seeded);
363
364 CREATE TABLE IF NOT EXISTS db_drone_definitions (
365 id TEXT PRIMARY KEY,
366 name TEXT NOT NULL,
367 description TEXT NOT NULL DEFAULT '',
368 graph TEXT NOT NULL DEFAULT '{\"nodes\":[],\"edges\":[]}',
369 viewport TEXT NOT NULL DEFAULT '{\"x\":0,\"y\":0,\"zoom\":1}',
370 created_at INTEGER NOT NULL DEFAULT 0,
371 updated_at INTEGER NOT NULL DEFAULT 0
372 );
373 CREATE INDEX IF NOT EXISTS idx_drone_definitions_updated
374 ON db_drone_definitions(updated_at DESC);
375
376 CREATE TABLE IF NOT EXISTS db_drone_runs (
377 id TEXT PRIMARY KEY,
378 drone_id TEXT NOT NULL,
379 status TEXT NOT NULL DEFAULT 'running',
380 started_at INTEGER NOT NULL DEFAULT 0,
381 ended_at INTEGER NOT NULL DEFAULT 0,
382 block_states TEXT NOT NULL DEFAULT '{}',
383 output TEXT NOT NULL DEFAULT '',
384 error TEXT NOT NULL DEFAULT '',
385 FOREIGN KEY (drone_id) REFERENCES db_drone_definitions(id) ON DELETE CASCADE
386 );
387 CREATE INDEX IF NOT EXISTS idx_drone_runs_drone_started
388 ON db_drone_runs(drone_id, started_at DESC);
389 CREATE INDEX IF NOT EXISTS idx_drone_runs_status
390 ON db_drone_runs(status);
391
392 -- v7: MuxBus cloud connectivity — global singleton PKCE token store.
393 CREATE TABLE IF NOT EXISTS db_muxbus_credentials (
394 id TEXT PRIMARY KEY DEFAULT 'global',
395 cognito_domain TEXT NOT NULL DEFAULT '',
396 client_id TEXT NOT NULL DEFAULT '',
397 access_token TEXT NOT NULL DEFAULT '',
398 refresh_token TEXT NOT NULL DEFAULT '',
399 id_token TEXT NOT NULL DEFAULT '',
400 expires_at INTEGER NOT NULL DEFAULT 0,
401 user_email TEXT NOT NULL DEFAULT '',
402 user_sub TEXT NOT NULL DEFAULT ''
403 );",
404 )?;
405
406 conn.execute_batch(
429 "CREATE TABLE IF NOT EXISTS db_muxbus_credentials (
430 id TEXT PRIMARY KEY DEFAULT 'global',
431 cognito_domain TEXT NOT NULL DEFAULT '',
432 client_id TEXT NOT NULL DEFAULT '',
433 access_token TEXT NOT NULL DEFAULT '',
434 refresh_token TEXT NOT NULL DEFAULT '',
435 id_token TEXT NOT NULL DEFAULT '',
436 expires_at INTEGER NOT NULL DEFAULT 0,
437 user_email TEXT NOT NULL DEFAULT '',
438 user_sub TEXT NOT NULL DEFAULT ''
439 )",
440 )?;
441
442 for stmt in &[
443 "ALTER TABLE db_agent_definitions ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0",
444 "ALTER TABLE db_agent_definitions ADD COLUMN user_hidden INTEGER NOT NULL DEFAULT 0",
445 "ALTER TABLE db_agents ADD COLUMN last_block_id TEXT NOT NULL DEFAULT ''",
446 "ALTER TABLE db_agent_definitions ADD COLUMN container_image TEXT NOT NULL DEFAULT ''",
447 "ALTER TABLE db_agent_definitions ADD COLUMN container_volumes TEXT NOT NULL DEFAULT '[]'",
448 "ALTER TABLE db_agent_definitions ADD COLUMN container_name TEXT NOT NULL DEFAULT ''",
449 "ALTER TABLE db_agents ADD COLUMN container_image TEXT NOT NULL DEFAULT ''",
450 "ALTER TABLE db_agents ADD COLUMN container_volumes TEXT NOT NULL DEFAULT '[]'",
451 "ALTER TABLE db_agents ADD COLUMN container_name TEXT NOT NULL DEFAULT ''",
452 "ALTER TABLE db_memory_bundles ADD COLUMN is_global INTEGER NOT NULL DEFAULT 0",
453 "ALTER TABLE db_memory_bundles ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
454 ] {
455 if let Err(e) = conn.execute_batch(stmt) {
456 let msg = e.to_string();
457 if !msg.contains("duplicate column") {
458 return Err(e.into());
459 }
460 }
461 }
462
463 conn.execute_batch(
468 "INSERT OR IGNORE INTO db_identity_bundles
469 (id, name, description, is_blank, created_at, updated_at)
470 VALUES ('blank', '__blank__', 'No credentials — use ambient', 1, 0, 0);
471
472 INSERT OR IGNORE INTO db_memory_bundles
473 (id, name, description, is_blank, created_at, updated_at)
474 VALUES ('blank', '__blank__', 'Vanilla CLI — no instructions, no context', 1, 0, 0);",
475 )?;
476
477 Ok(())
478}
479
480fn adopt_legacy_table_names(conn: &Connection) -> Result<(), StoreError> {
490 for (legacy, current) in LEGACY_TABLE_RENAMES {
491 let legacy_exists: i64 = conn.query_row(
492 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
493 [legacy],
494 |row| row.get(0),
495 )?;
496 if legacy_exists == 0 {
497 continue;
498 }
499 let current_exists: i64 = conn.query_row(
500 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
501 [current],
502 |row| row.get(0),
503 )?;
504 if current_exists == 1 {
505 warn!(
514 legacy_table = *legacy,
515 current_table = *current,
516 "objects.db has both a legacy table and its de-forged \
517 replacement — this only happens after a downgrade to a \
518 pre-flatten build; the legacy table is left untouched \
519 for manual recovery and is otherwise unused",
520 );
521 } else {
522 conn.execute_batch(&format!("ALTER TABLE {legacy} RENAME TO {current};"))?;
523 }
524 }
525
526 for idx in LEGACY_INDEX_DROPS {
529 conn.execute_batch(&format!("DROP INDEX IF EXISTS {idx};"))?;
530 }
531
532 for table in DEAD_TABLE_DROPS {
534 conn.execute_batch(&format!("DROP TABLE IF EXISTS {table};"))?;
535 }
536
537 Ok(())
538}
539
540pub fn run_filestore_migrations(conn: &Connection) -> Result<(), StoreError> {
544 conn.execute_batch(
545 "CREATE TABLE IF NOT EXISTS db_wave_file (
546 zoneid TEXT NOT NULL,
547 name TEXT NOT NULL,
548 size INTEGER NOT NULL DEFAULT 0,
549 createdts INTEGER NOT NULL DEFAULT 0,
550 modts INTEGER NOT NULL DEFAULT 0,
551 opts TEXT NOT NULL DEFAULT '{}',
552 meta TEXT NOT NULL DEFAULT '{}',
553 PRIMARY KEY (zoneid, name)
554 );
555
556 CREATE TABLE IF NOT EXISTS db_file_data (
557 zoneid TEXT NOT NULL,
558 name TEXT NOT NULL,
559 partidx INTEGER NOT NULL,
560 data BLOB NOT NULL,
561 PRIMARY KEY (zoneid, name, partidx)
562 );",
563 )?;
564 Ok(())
565}
566
567pub fn run_saga_log_migrations(conn: &Connection) -> Result<(), StoreError> {
571 conn.execute_batch(
572 "CREATE TABLE IF NOT EXISTS saga (
573 saga_id INTEGER PRIMARY KEY,
574 name TEXT NOT NULL,
575 state TEXT NOT NULL,
576 started_at INTEGER NOT NULL,
577 terminal_at INTEGER,
578 failure_reason TEXT,
579 input_json TEXT NOT NULL
580 );
581
582 CREATE TABLE IF NOT EXISTS saga_step (
583 saga_id INTEGER NOT NULL REFERENCES saga(saga_id),
584 step_index INTEGER NOT NULL,
585 name TEXT NOT NULL,
586 state TEXT NOT NULL,
587 cmd_json TEXT NOT NULL,
588 output_json TEXT,
589 started_at INTEGER NOT NULL,
590 ended_at INTEGER,
591 PRIMARY KEY (saga_id, step_index)
592 );
593
594 CREATE INDEX IF NOT EXISTS saga_state_idx
595 ON saga(state) WHERE state IN ('running', 'compensating');
596 CREATE INDEX IF NOT EXISTS saga_terminal_idx
597 ON saga(terminal_at);",
598 )?;
599 Ok(())
600}
601
602pub fn check_schema_compat(
633 conn: &Connection,
634 current: i64,
635 db_label: &str,
636) -> Result<(), StoreError> {
637 let found: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
638 if found > current {
639 warn!(
640 db = db_label,
641 found, expected = current,
642 "database user_version is newer than this build — refusing \
643 to open. Upgrade AgentMux or switch channels.",
644 );
645 return Err(StoreError::SchemaTooNew {
646 db: db_label.to_string(),
647 found,
648 expected: current,
649 });
650 }
651 Ok(())
652}
653
654pub fn stamp_version(conn: &Connection, current: i64) -> Result<(), StoreError> {
668 conn.execute_batch(&format!("PRAGMA user_version = {current};"))?;
669 Ok(())
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 const EXPECTED_TABLES: &[&str] = &[
678 "db_client",
679 "db_window",
680 "db_workspace",
681 "db_tab",
682 "db_layout",
683 "db_block",
684 "db_temp",
685 "db_agent_definitions",
686 "db_agent_content",
687 "db_agent_skills",
688 "db_agent_history",
689 "db_identity_accounts",
690 "db_agent_identity_links",
691 "db_identity_bundles",
692 "db_identity_bindings",
693 "db_memory_bundles",
694 "db_agent_instances",
695 "db_agents",
696 "db_drone_definitions",
697 "db_drone_runs",
698 ];
699
700 fn table_exists(conn: &Connection, name: &str) -> bool {
701 let count: i64 = conn
702 .query_row(
703 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
704 [name],
705 |row| row.get(0),
706 )
707 .unwrap();
708 count == 1
709 }
710
711 fn index_exists(conn: &Connection, name: &str) -> bool {
712 let count: i64 = conn
713 .query_row(
714 "SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?1",
715 [name],
716 |row| row.get(0),
717 )
718 .unwrap();
719 count == 1
720 }
721
722 #[test]
723 fn test_object_schema_creates_all_tables_and_singletons() {
724 let conn = Connection::open_in_memory().unwrap();
725 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
726 run_object_schema(&conn).unwrap();
727
728 for table in EXPECTED_TABLES {
729 assert!(table_exists(&conn, table), "{table} should exist");
730 }
731 for idx in &[
733 "idx_agent_definitions_slug",
734 "idx_agent_history_agent_date",
735 "idx_agent_identity_links_account",
736 "idx_identity_accounts_provider",
737 "idx_identity_bundles_is_blank",
738 "idx_identity_bindings_account",
739 "idx_memory_bundles_is_blank",
740 "idx_agent_instances_definition",
741 "idx_agent_instances_name_recent",
742 "idx_agents_is_template",
743 "idx_agents_parent_template_id",
744 "idx_agents_is_seeded",
745 "idx_drone_definitions_updated",
746 "idx_drone_runs_status",
747 ] {
748 assert!(index_exists(&conn, idx), "{idx} should exist");
749 }
750
751 let id_blank: i64 = conn
753 .query_row(
754 "SELECT count(*) FROM db_identity_bundles WHERE id='blank' AND is_blank=1",
755 [],
756 |row| row.get(0),
757 )
758 .unwrap();
759 assert_eq!(id_blank, 1, "blank Identity singleton should be seeded");
760 let mem_blank: i64 = conn
761 .query_row(
762 "SELECT count(*) FROM db_memory_bundles WHERE id='blank' AND is_blank=1",
763 [],
764 |row| row.get(0),
765 )
766 .unwrap();
767 assert_eq!(mem_blank, 1, "blank Memory singleton should be seeded");
768 }
769
770 #[test]
771 fn test_object_schema_idempotent() {
772 let conn = Connection::open_in_memory().unwrap();
773 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
774 run_object_schema(&conn).unwrap();
775 run_object_schema(&conn).unwrap(); let id_count: i64 = conn
779 .query_row("SELECT count(*) FROM db_identity_bundles", [], |r| r.get(0))
780 .unwrap();
781 assert_eq!(id_count, 1);
782 }
783
784 #[test]
785 fn test_object_schema_omits_dead_tables() {
786 let conn = Connection::open_in_memory().unwrap();
787 run_object_schema(&conn).unwrap();
788 for dead in DEAD_TABLE_DROPS {
789 assert!(!table_exists(&conn, dead), "{dead} must not be created");
790 }
791 for (legacy, _) in LEGACY_TABLE_RENAMES {
793 assert!(
794 !table_exists(&conn, legacy),
795 "legacy {legacy} must not be created by the flat schema"
796 );
797 }
798 }
799
800 #[test]
801 fn test_adopt_legacy_renames_forge_tables() {
802 let conn = Connection::open_in_memory().unwrap();
805 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
806 conn.execute_batch(
807 "CREATE TABLE db_forge_agents (
808 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
809 icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
810 description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
811 shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
812 auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
813 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
814 agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
815 agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
816 accounts TEXT NOT NULL DEFAULT '',
817 parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
818 created_at INTEGER NOT NULL DEFAULT 0
819 );
820 CREATE UNIQUE INDEX idx_forge_agents_slug ON db_forge_agents(slug);
821 INSERT INTO db_forge_agents (id, slug, name, provider)
822 VALUES ('a1', 'coder', 'Coder', 'claude');
823
824 CREATE TABLE db_workflow_definitions (id TEXT PRIMARY KEY);",
825 )
826 .unwrap();
827
828 run_object_schema(&conn).unwrap();
829
830 assert!(table_exists(&conn, "db_agent_definitions"));
832 assert!(!table_exists(&conn, "db_forge_agents"));
833 let name: String = conn
834 .query_row(
835 "SELECT name FROM db_agent_definitions WHERE id='a1'",
836 [],
837 |r| r.get(0),
838 )
839 .unwrap();
840 assert_eq!(name, "Coder");
841 assert!(!index_exists(&conn, "idx_forge_agents_slug"));
843 assert!(index_exists(&conn, "idx_agent_definitions_slug"));
844 assert!(!table_exists(&conn, "db_workflow_definitions"));
846 }
847
848 #[test]
849 fn test_adopt_legacy_is_noop_on_fresh_db() {
850 let conn = Connection::open_in_memory().unwrap();
851 run_object_schema(&conn).unwrap();
852 run_object_schema(&conn).unwrap();
855 assert!(table_exists(&conn, "db_agent_definitions"));
856 assert!(!table_exists(&conn, "db_forge_agents"));
857 }
858
859 #[test]
860 fn test_adopt_legacy_both_tables_present_is_non_destructive() {
861 let conn = Connection::open_in_memory().unwrap();
866 run_object_schema(&conn).unwrap(); conn.execute_batch(
868 "CREATE TABLE db_forge_agents (id TEXT PRIMARY KEY, name TEXT NOT NULL);
869 INSERT INTO db_forge_agents (id, name) VALUES ('downgrade-era', 'Recover Me');",
870 )
871 .unwrap();
872
873 run_object_schema(&conn).unwrap();
874
875 assert!(table_exists(&conn, "db_forge_agents"));
877 let name: String = conn
878 .query_row(
879 "SELECT name FROM db_forge_agents WHERE id='downgrade-era'",
880 [],
881 |r| r.get(0),
882 )
883 .unwrap();
884 assert_eq!(name, "Recover Me");
885 assert!(table_exists(&conn, "db_agent_definitions"));
887 }
888
889 #[test]
890 fn test_adopt_legacy_fk_cascade_survives_rename() {
891 let conn = Connection::open_in_memory().unwrap();
893 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
894 conn.execute_batch(
895 "CREATE TABLE db_forge_agents (
896 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
897 icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
898 description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
899 shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
900 auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
901 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
902 agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
903 agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
904 accounts TEXT NOT NULL DEFAULT '',
905 parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
906 created_at INTEGER NOT NULL DEFAULT 0
907 );
908 CREATE TABLE db_forge_content (
909 agent_id TEXT NOT NULL, content_type TEXT NOT NULL,
910 content TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0,
911 PRIMARY KEY (agent_id, content_type),
912 FOREIGN KEY (agent_id) REFERENCES db_forge_agents(id) ON DELETE CASCADE
913 );
914 INSERT INTO db_forge_agents (id, name, provider) VALUES ('a1', 'Coder', 'claude');
915 INSERT INTO db_forge_content (agent_id, content_type, content)
916 VALUES ('a1', 'soul', 'hello');",
917 )
918 .unwrap();
919
920 run_object_schema(&conn).unwrap();
921
922 conn.execute("DELETE FROM db_agent_definitions WHERE id='a1'", [])
923 .unwrap();
924 let remaining: i64 = conn
925 .query_row(
926 "SELECT count(*) FROM db_agent_content WHERE agent_id='a1'",
927 [],
928 |r| r.get(0),
929 )
930 .unwrap();
931 assert_eq!(
932 remaining, 0,
933 "FK cascade must survive the forge→agent table rename"
934 );
935 }
936
937 #[test]
938 fn test_user_hidden_column_present_on_fresh_db() {
939 let conn = Connection::open_in_memory().unwrap();
944 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
945 run_object_schema(&conn).unwrap();
946
947 conn.execute_batch(
950 "INSERT INTO db_agent_definitions (id, name, provider)
951 VALUES ('a-fresh', 'Fresh', 'claude');",
952 )
953 .unwrap();
954 let hidden: i64 = conn
955 .query_row(
956 "SELECT user_hidden FROM db_agent_definitions WHERE id='a-fresh'",
957 [],
958 |r| r.get(0),
959 )
960 .unwrap();
961 assert_eq!(hidden, 0);
962 }
963
964 #[test]
965 fn test_user_hidden_column_added_to_existing_db_via_alter() {
966 let conn = Connection::open_in_memory().unwrap();
971 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
972 conn.execute_batch(
973 "CREATE TABLE db_agent_definitions (
974 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '',
975 name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '✦',
976 provider TEXT NOT NULL,
977 description TEXT NOT NULL DEFAULT '',
978 working_directory TEXT NOT NULL DEFAULT '',
979 shell TEXT NOT NULL DEFAULT '',
980 provider_flags TEXT NOT NULL DEFAULT '',
981 auto_start INTEGER NOT NULL DEFAULT 0,
982 restart_on_crash INTEGER NOT NULL DEFAULT 0,
983 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
984 agent_type TEXT NOT NULL DEFAULT 'standalone',
985 environment TEXT NOT NULL DEFAULT '',
986 agent_bus_id TEXT NOT NULL DEFAULT '',
987 is_seeded INTEGER NOT NULL DEFAULT 0,
988 accounts TEXT NOT NULL DEFAULT '',
989 parent_id TEXT NOT NULL DEFAULT '',
990 branch_label TEXT NOT NULL DEFAULT '',
991 created_at INTEGER NOT NULL DEFAULT 0
992 );
993 INSERT INTO db_agent_definitions (id, name, provider, is_seeded)
994 VALUES ('pre-existing', 'Old Template', 'claude', 1);",
995 )
996 .unwrap();
997
998 run_object_schema(&conn).unwrap();
999 run_object_schema(&conn).unwrap();
1002
1003 let hidden: i64 = conn
1004 .query_row(
1005 "SELECT user_hidden FROM db_agent_definitions WHERE id='pre-existing'",
1006 [],
1007 |r| r.get(0),
1008 )
1009 .unwrap();
1010 assert_eq!(
1011 hidden, 0,
1012 "ALTER must default existing rows to 0 (visible), never to 1",
1013 );
1014 }
1015
1016 #[test]
1017 fn stamp_version_writes_pragma() {
1018 let conn = Connection::open_in_memory().unwrap();
1019 stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
1020 let v: i64 = conn
1021 .query_row("PRAGMA user_version", [], |r| r.get(0))
1022 .unwrap();
1023 assert_eq!(v, OBJECT_SCHEMA_VERSION);
1024 }
1025
1026 #[test]
1027 fn check_schema_compat_refuses_newer_db_without_writing() {
1028 let conn = Connection::open_in_memory().unwrap();
1037 conn.execute_batch("PRAGMA user_version = 99;").unwrap();
1038 let err = check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")
1039 .expect_err("expected refusal");
1040 match err {
1041 StoreError::SchemaTooNew { db, found, expected } => {
1042 assert_eq!(db, "objects.db");
1043 assert_eq!(found, 99);
1044 assert_eq!(expected, OBJECT_SCHEMA_VERSION);
1045 }
1046 other => panic!("expected SchemaTooNew, got {other:?}"),
1047 }
1048 let v: i64 = conn
1056 .query_row("PRAGMA user_version", [], |r| r.get(0))
1057 .unwrap();
1058 assert_eq!(v, 99, "rejected DB must not have its user_version stamp overwritten");
1059 }
1060
1061 #[test]
1062 fn check_schema_compat_accepts_equal_or_lower_without_writing() {
1063 let conn = Connection::open_in_memory().unwrap();
1071 conn.execute_batch(&format!("PRAGMA user_version = {};", OBJECT_SCHEMA_VERSION))
1072 .unwrap();
1073 check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
1074 let v: i64 = conn
1075 .query_row("PRAGMA user_version", [], |r| r.get(0))
1076 .unwrap();
1077 assert_eq!(v, OBJECT_SCHEMA_VERSION);
1078
1079 let conn = Connection::open_in_memory().unwrap();
1083 conn.execute_batch("PRAGMA user_version = 1;").unwrap();
1084 check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
1085 let v: i64 = conn
1086 .query_row("PRAGMA user_version", [], |r| r.get(0))
1087 .unwrap();
1088 assert_eq!(v, 1, "check_schema_compat must not write to user_version");
1089
1090 stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
1092 let v: i64 = conn
1093 .query_row("PRAGMA user_version", [], |r| r.get(0))
1094 .unwrap();
1095 assert_eq!(v, OBJECT_SCHEMA_VERSION);
1096 }
1097
1098 #[test]
1099 fn test_filestore_migrations_idempotent() {
1100 let conn = Connection::open_in_memory().unwrap();
1101 run_filestore_migrations(&conn).unwrap();
1102 run_filestore_migrations(&conn).unwrap();
1103 assert!(table_exists(&conn, "db_wave_file"));
1104 assert!(table_exists(&conn, "db_file_data"));
1105 }
1106
1107 #[test]
1108 fn test_saga_log_migrations_idempotent() {
1109 let conn = Connection::open_in_memory().unwrap();
1110 run_saga_log_migrations(&conn).unwrap();
1111 run_saga_log_migrations(&conn).unwrap();
1112 assert!(table_exists(&conn, "saga"));
1113 assert!(table_exists(&conn, "saga_step"));
1114 }
1115}